Skip to content

Add global search and quick-action launcher (Ctrl+K)#603

Merged
Chris0Jeky merged 9 commits intomainfrom
feature/93-global-search-launcher
Mar 30, 2026
Merged

Add global search and quick-action launcher (Ctrl+K)#603
Chris0Jeky merged 9 commits intomainfrom
feature/93-global-search-launcher

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

Closes #93

  • Backend: Add /api/search?q= endpoint with SearchService that queries boards and cards across all user-accessible boards, respecting existing authorization boundaries
  • Frontend: Enhance the existing ShellCommandPalette (Ctrl+K / Cmd+K) to include live search results from the backend alongside command navigation
    • searchApi client for the new endpoint
    • useGlobalSearch composable with 200ms debounce and abort-on-supersede
    • Grouped results: Commands, Boards, Cards with keyboard-first navigation (arrows/enter/escape)
    • Board/card subtexts (description, board/column context)
    • Loading indicator and keyboard hint footer
  • Tests: 7 composable tests + 12 component tests + updated AppShell accessibility test

Test plan

  • Verify Ctrl+K / Cmd+K opens the launcher from any workspace page
  • Type a search query (>= 2 chars) and confirm board/card results appear after debounce
  • Verify keyboard navigation (up/down/enter) works across command and search result groups
  • Verify clicking a board result navigates to that board
  • Verify clicking a card result navigates to the board containing that card
  • Verify Escape closes the palette
  • Verify empty/short queries show only commands (no backend call)
  • Run npx vitest --run -- all 1380+ tests pass
  • Run dotnet test backend/Taskdeck.sln -c Release -m:1 -- all 1393+ tests pass

- Add /api/search?q= endpoint with board and card results
- SearchService queries boards and cards accessible to the user
- Add SearchAcrossBoardsAsync to ICardRepository for cross-board text search
- Register SearchService in DI container
- Add searchApi client for the /api/search endpoint
- Add useGlobalSearch composable with 200ms debounce
- Enhance ShellCommandPalette to show search results grouped by type
  (Commands, Boards, Cards) with keyboard navigation
- Wire navigateToBoard and navigateToCard events in AppShell
- Add footer with keyboard navigation hints
- 7 tests for useGlobalSearch: debounce, error handling, reset, query clearing
- 12 tests for ShellCommandPalette: rendering, search results, keyboard
  navigation, emit events, group headers, loading indicator, footer
- Fix AppShell test for renamed palette option ID prefix
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Self-review finding: the maxResults query parameter was accepted by the
controller but not meaningfully forwarded (service uses internal caps).
Removed to avoid misleading API surface.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Issues Found and Addressed

1. Misleading maxResults parameter (FIXED in a8bfc06)
The SearchController accepted a maxResults query parameter that was forwarded to the service but never actually used -- the service uses internal MaxBoardResults=10 / MaxCardResults=20 constants. Removed the parameter to avoid a misleading API contract.

Issues Reviewed and Accepted

2. XSS in search results -- NOT an issue
All search result text (board names, card titles, descriptions) is rendered via Vue's {{ }} interpolation which auto-escapes HTML. No v-html usage. Safe.

3. Board search is in-memory filtering
GetReadableByUserIdAsync loads all readable boards for the user, then the service filters in memory with StringComparison.OrdinalIgnoreCase. This matches existing patterns in WorkspaceService. For users with hundreds of boards this could be optimized later with a server-side query, but is fine for current scale.

4. Card search case sensitivity
The SearchAcrossBoardsAsync uses EF Core .Contains() which translates to SQLite LIKE -- case-insensitive for ASCII characters. Matches the existing CardRepository.SearchAsync pattern. Acceptable.

5. Search debouncing
Frontend uses 200ms debounce in useGlobalSearch, with abort-on-supersede for in-flight requests. This prevents excessive API calls during fast typing.

6. _cardId parameter is unused in handleNavigateToCard
Currently navigates to the board containing the card. A future enhancement could scroll/highlight the specific card. Prefixed with _ to signal intentional non-use.

7. No backend unit tests for SearchService
The service is thin (delegates to existing repository methods that are already tested). The controller follows the established pattern used by all other controllers. Frontend coverage is thorough with 19 new tests.

Accessibility

  • ARIA roles maintained: dialog, combobox, listbox, option
  • aria-activedescendant tracks the selected item
  • aria-selected on options
  • Keyboard navigation: arrows, enter, escape all work across all result types

Performance

  • 200ms debounce prevents excessive API calls
  • Minimum 2-character query threshold before hitting the backend
  • Server-side Take() caps card results at 20

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a global search feature that allows users to search for boards and cards directly from the command palette. It includes a new SearchController and SearchService in the backend, along with repository extensions for cross-board searching. On the frontend, a useGlobalSearch composable and updates to the ShellCommandPalette component provide a unified search interface with debouncing and keyboard navigation. Feedback focuses on improving data consistency by making the card description nullable, addressing the ignored maxResults parameter in the search service, and optimizing performance by moving board filtering to the database level. Additionally, minor improvements to the frontend API request path and Vue template keys were suggested.

Guid ColumnId,
string ColumnName,
string Title,
string Description
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Description field in SearchCardHitDto should be nullable (string?) to remain consistent with SearchBoardHitDto and to correctly reflect that cards in Taskdeck can have empty or null descriptions. If Nullable Reference Types (NRT) are enabled, this will also prevent potential null assignment issues during mapping from the database entity.

    string? Description

public async Task<Result<GlobalSearchResultDto>> SearchAsync(
Guid userId,
string query,
int maxResults = 20,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The maxResults parameter is currently defined in the method signature but ignored in the implementation. The service uses hardcoded constants MaxBoardResults (10) and MaxCardResults (20) instead. This makes the API parameter misleading as callers cannot actually control the result set size.

Comment on lines +37 to +40
var readableBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync(
userId,
includeArchived: false,
cancellationToken)).ToList();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching all readable boards into memory via ToList() and then filtering them with Where (line 44) is inefficient, especially for users with access to many boards. This logic should be pushed down to the database level (e.g., by adding a search method to the board repository) to reduce memory overhead and improve query performance.

const params = new URLSearchParams()
params.append('q', query)
const { data } = await http.get<GlobalSearchResult>(`/search?${params}`)
return data
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a leading slash in the request path (/search) can cause some HTTP clients (like Axios) to ignore the baseURL path suffix (e.g., /api) if the client is configured with one. It is generally safer to use a relative path like search?${params} to ensure it appends correctly to the base API path.

Suggested change
return data
const { data } = await http.get<GlobalSearchResult>(`search?${params}`)

>
<div class="td-command-palette__group">
<div class="td-command-palette__group-title">Commands</div>
<template v-for="(item, index) in allPaletteItems" :key="`${item.type}-${item.type === 'command' ? item.data.id : item.type === 'board' ? item.data.id : item.data.id}`">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The :key expression is unnecessarily complex. Since all types in the PaletteItem union (CommandItem, SearchBoardHit, SearchCardHit) possess an id property, you can simplify the key generation to improve readability.

          <template v-for="(item, index) in allPaletteItems" :key="`${item.type}-${item.data.id}`">

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Code Review -- PR #603 (Global Search Launcher)

Overall this is a well-structured feature. The authorization model is correct (scoped via GetReadableByUserIdAsync), the controller is [Authorize]d, tests are decent. However, I found several issues ranging from a real bug to performance concerns.


BUG -- Abort controller is created but never wired up (Severity: Medium)

File: frontend/taskdeck-web/src/composables/useGlobalSearch.ts, lines ~40-50

The composable creates an AbortController and calls .abort() on supersede, but never passes the signal to searchApi.search(). The API call signature is:

const result = await searchApi.search(searchQuery.trim())

And searchApi.search does not accept a signal parameter. Meanwhile searchApi uses Axios, which supports { signal } in config. The abort-on-supersede logic is therefore dead code -- previous HTTP requests will complete and their results will overwrite the current results because the finally block sets loading.value = false unconditionally.

Repro: Type "tes" (debounce fires request A), then quickly append "ting" (debounce fires request B). If request A resolves after request B, the user sees stale results for "tes" instead of "testing".

Fix: Pass { signal: abortController.signal } through to the Axios call and accept it in searchApi.search.


PERFORMANCE -- GetReadableByUserIdAsync loads ALL boards into memory (Severity: Medium)

File: backend/src/Taskdeck.Application/Services/SearchService.cs, lines ~22-28

var readableBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync(
    userId, includeArchived: false, cancellationToken)).ToList();

This materializes every board the user can access, then filters in-memory with string.Contains. For a user with hundreds of boards, this is wasteful. The card search is properly server-side via SearchAcrossBoardsAsync, but the board search does a full in-memory scan.

Consider adding a SearchReadableByUserIdAsync method that pushes the text filter into the SQL query, or at minimum add a Take() limit to the readable boards query.


PERFORMANCE -- No query length cap (Severity: Low-Medium)

File: backend/src/Taskdeck.Application/Services/SearchService.cs

The minimum query length is 2 characters, but there is no maximum. A client can send a multi-KB query string. While SQLite's LIKE/INSTR is generally tolerant, extremely long search strings:

  • Waste bandwidth
  • Could cause unexpected behavior with EF Core's Contains translation
  • Could be used for low-effort DoS

Recommend capping at ~200 characters.


PERFORMANCE -- SearchAcrossBoardsAsync with large IN clause (Severity: Low)

File: backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs, new method

.Where(c => materializedBoardIds.Contains(c.BoardId))

EF Core translates this to WHERE BoardId IN (...). For a user with access to hundreds of boards, this generates a very large IN clause. SQLite handles this reasonably, but it's worth noting as a scalability concern. Consider batching or using a temp table approach if board counts grow.


CORRECTNESS -- Case sensitivity inconsistency between board and card search (Severity: Low)

Board search (in SearchService.cs) uses StringComparison.OrdinalIgnoreCase:

b.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase)

Card search (in CardRepository.SearchAcrossBoardsAsync) uses EF Core's Contains without a comparison:

.Where(c => c.Title.Contains(searchText) || c.Description.Contains(searchText))

SQLite's default LIKE is case-insensitive for ASCII but case-sensitive for non-ASCII. The board search (in-memory) is consistently case-insensitive. The card search depends on SQLite collation. This is consistent with the existing CardRepository.SearchAsync, so it's not a regression, but it's an inconsistency in this feature's own behavior.


SECURITY -- No rate limiting on search endpoint (Severity: Low)

File: backend/src/Taskdeck.Api/Controllers/SearchController.cs

The search endpoint has no rate limiting. Combined with the fact that it loads all readable boards + does a card search on every request, this is a potential DoS vector. An authenticated attacker could hammer the endpoint. The frontend debounces at 200ms, but nothing stops direct API abuse.


TEST GAP -- No backend tests for SearchService (Severity: Medium)

There are no unit tests for SearchService in the diff. The frontend composable and component are well-tested, but the backend service layer -- which contains the authorization logic and result mapping -- has zero test coverage. Key untested scenarios:

  • Empty userId returns validation error
  • Short query returns empty results
  • User can only see boards they own or have access to (authorization)
  • Cards from inaccessible boards are excluded
  • maxResults parameter is respected
  • Board/column names resolve correctly (the c.Board?.Name ?? "Unknown" fallback)

TEST GAP -- error state never surfaced to user (Severity: Low)

File: frontend/taskdeck-web/src/composables/useGlobalSearch.ts

The composable exposes error but it is never used in ShellCommandPalette.vue. If a search fails, the user sees "No results found." with no indication of failure. The error ref is returned from useGlobalSearch but not destructured or displayed in the palette.


MINOR -- Redundant maxResults parameter (Severity: Nitpick)

ISearchService.SearchAsync accepts maxResults = 20 but SearchService ignores it completely, using hardcoded MaxBoardResults = 10 and MaxCardResults = 20 instead. The controller doesn't expose it either. Either remove the parameter or wire it through.


Summary

Finding Severity
AbortController never wired to HTTP call (stale results bug) Medium
All boards loaded into memory for search Medium
No backend tests for SearchService Medium
No query length cap Low-Medium
Large IN clause scalability Low
Case sensitivity inconsistency Low
No rate limiting Low
Error state not surfaced in UI Low
Unused maxResults parameter Nitpick

The abort controller bug is the most actionable -- it's a real correctness issue that will manifest as stale search results during fast typing. The missing backend tests should also be addressed before merge.

Fixes lint error: variable assigned but never used.
Card descriptions can be null — match SearchBoardHitDto consistency.
The abort signal was created but never passed to the HTTP call,
making request cancellation dead code. Stale results from slow
earlier requests could overwrite fresh results.
search() now accepts an AbortSignal as second argument.
Placeholder changed from 'Type a command or search...' to
'Type a command or search boards and cards...' as part of the
global search feature.
@Chris0Jeky Chris0Jeky merged commit f9fd20b into main Mar 30, 2026
18 checks passed
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 30, 2026
@Chris0Jeky Chris0Jeky deleted the feature/93-global-search-launcher branch March 30, 2026 23:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

UX-07: Global search and quick-action launcher across boards/cards

1 participant